iT邦幫忙

2022 iThome 鐵人賽

DAY 8
0
Modern Web

LV的全端開發體驗系列 第 8

Day08 基本CRUD測試-新增題目,編輯,刪除

  • 分享至 

  • xImage
  •  

大概確定了前後台的畫面後,終於可以來做點功能了,今天的目標是建立題目,然後完成後台的CRUD測試。

只要有題目,就可以開始測驗了,試卷是另一種測驗型式,由老師指定題目內容來進行測驗,而一般的測驗則是從題目中亂數選題來測驗。

在我的計畫中,題目之上還有一個題庫做為所有題目的分類,但今天只想先確認題目的CRUD是可以動的,所以題庫的問題我們之後再說。

我們先定義一下何謂題庫:

  • 有一個主題:比如網頁設計丙級,網頁設計乙級。
  • 主題下可以分類別:比如作業準備、應用軟體安裝及使用...。
  • 題庫中的每一個題目先暫定只有四個選項。
  • 有單選題和複選題。
  • 先不考慮題目或選項中有圖片的狀況,假設都是文字,比較好處理。

由於我們在出測驗時,選項不會每次都一樣的順序,因此比較好的做法是把題目和選項分兩個資料表來處理,想法有了,就來建資料表吧。

在Laravel中,可以在建Model時同時建立migration:

php artisan make:model Subject -m
php artisan make:model Option -m

設計欄位:
xxxxx_create_subjects_table

.....略
return new class extends Migration
{
    public function up()
    {
        Schema::create('subjects', function (Blueprint $table) {
            $table->id();
            $table->unsignedSmallInteger('seq');        //原始題號順序
            $table->text('subject');
            $table->string('code');                     //題庫代碼
            $table->unsignedTinyInteger('level');       //題庫級別、甲,乙,丙,單一
            $table->unsignedTinyInteger('group');       //題組
            $table->boolean('multiple')->default(false);//單複選
            $table->timestamp('created_at')->useCurrent();
            $table->timestamp('updated_at')->useCurrent()
                                           ->useCurrentOnUpdate();
        });
    }

    public function down()
    {
        Schema::dropIfExists('subjects');
    }
};

xxxxxx_create_options_table

.....略
return new class extends Migration
{
    public function up()
    {
        Schema::create('options', function (Blueprint $table) {
            $table->id();
            $table->text('option');
            $table->unsignedInteger('subject_id');
            $table->boolean('ans');
        });
    }

    public function down()
    {
        Schema::dropIfExists('options');
    }
};

順便把Model及Relation做一下:

Subject

.....略
class Subject extends Model
{
    use HasFactory;
    protected $guarded=[];
    protected $hidden=['created_at','updated_at'];

    function options()
    {
       return $this->hasMany(Option::class);
    }
}

Option

.....略
class Option extends Model
{
    use HasFactory;    
    protected $guarded=[];
    public $timestamps = false;  //不使用timestamp的兩個欄位

    function subject()
    {
        return $this->belongsTo(Subject::class);
    }
}

執行migrate:

php artisan migrate

新增題目(Create)

好,現在有資料表了,雖然畫面文字是題庫,但目前先拿來測試新增題目,把新增題庫的按鈕改成Inertia的 組件,使用它來幫我們載入表單頁面:
resources\js\Pages\Backstage\Banks.vue

<Link :href="route(bank.create)"
      class="inline-block py-2 px-3 border rounded-xl bg-blue-700 text-blue-100 my-4">
    新增題庫
</Link>

新增路由,因為新增的表單是靜態的頁面,目前沒有要接收來自後端的資料,所以可以直接在路由定義輸出的頁面,效果等同laravel的 Route::view
routes\web.php

Route::inertia('/backstage/bank/create','Backstage/CreateBank')->name('bank.create');


畫面很醜我知道,我們先確認一下這個流程沒問題,後面有大把大把的時間來慢慢調畫面。

因為我的題庫來源除了自建題庫外,有一大部份是來自於技能檢定中心的學科題庫,這部份的題庫都是有編號的,所以用別人有的就可以了,之後會把題庫獨立成一個功能類別,到時可以改成選單的型式;

而題組的部分和題庫編號一樣,除了號碼,也會有對應的中文,中文的部份我們後面會建一張資料表來做管理,目前先簡單做就好。

題號也是根據原本的題庫有的題號來填寫就可以了,如果是自訂題庫,當然就看自己愛怎麼寫都可以。

最後一個注意的事項是選項存入資料表之前必須先拿到題目的id,所以這個表單雖然是同時送出題目和選項,但是後端的處理順序應該是先儲存題目進資料表,然後拿到題目的id後,再去存選項,並把題目的id帶入,這樣才能確保題目和選項的關聯。

我們使用 Inertia 的 Form 工具來做表單資料的綁定:
resources\js\Pages\Backstage\CreateBank.vue

<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link, useForm } from "@inertiajs/inertia-vue3";

//Inertia的表單物件
const form = useForm({
  code: "",
  level: "",
  group: "",
  multiple: 0,
  seq: 1,
  subject: "",
  options: ["", "", "", ""],
  ans: [false, false, false, false],
});

//表單傳送時改用Inertia的Form組件來傳送,這是一個ajax的傳送
const submit = () => {
  form.post(route("bank.store"));
};
</script>

<template>
  <Head title="管理中心" />

  <AuthenticatedLayout>
    <template #header>
      <h2 class="font-semibold text-xl text-gray-800 leading-tight">管理中心</h2>
    </template>
     //.....略過左側選單
    <div class="w-5/6 border rounded-xl p-4">
        <!--利用Vue的動作修飾詞來中斷表單submit的動作,並觸發自訂的submit函式-->
      <form @submit.prevent="submit">
        <div>
          <label>題庫編號:</label>
          <input type="number" name="code" v-model="form.code" class="w-24" />
          <label>級別:</label>
          <input type="number" name="level" v-model="form.level" class="w-12" />
          <label>題組:</label>
          <input type="number" name="group" v-model="form.group" class="w-12" />
          <input type="radio" name="multiple" v-model="form.multiple" value="0" />單選
          <input type="radio" name="multiple" v-model="form.multiple" value="1" />複選
        </div>
        <div class="my-1">
          <label>題號:</label>
          <input type="number" name="seq" min="1" v-model="form.seq" />
        </div>
        <div class="my-1">
          <label>題目:</label>
          <input type="text" name="subject" v-model="form.subject" class="w-[90%]" />
        </div>
        <div class="my-1">
          <label for="">選項1:</label>
          <input type="text" class="w-[90%]" v-model="form.options[0]" />
          <input type="checkbox" v-model="form.ans[0]" />
        </div>
        <div class="my-1">
          <label for="">選項2:</label>
          <input type="text" class="w-[90%]" v-model="form.options[1]" />
          <input type="checkbox" v-model="form.ans[1]" />
        </div>
        <div class="my-1">
          <label for="">選項3:</label>
          <input type="text" class="w-[90%]" v-model="form.options[2]" />
          <input type="checkbox" v-model="form.ans[2]" />
        </div>
        <div class="my-1">
          <label for="">選項4:</label>
          <input type="text" class="w-[90%]" v-model="form.options[3]" />
          <input type="checkbox" v-model="form.ans[3]" />
        </div>
        <button type="submit"
                class="border py-2 px-4 bg-blue-700 text-blue-100 rounded-xl my-2">
          新增
        </button>
      </form>
    </div>
  </AuthenticatedLayout>
</template>

增加一個用來儲存新資料的路由:

Route::post('/backstage/bank',[BackstageController::class,'store'])->name('bank.store');

在Controller中撰寫儲存題庫的動作。
app\Http\Controllers\BackstageController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\Auth;
use App\Models\Subject;
use App\Models\Option;

class BackstageController extends Controller
{
    //......略`
    function store(Request $request)
    {
        $subject=new Subject;
        $subject->subject=$request->subject;
        $subject->seq=$request->seq;
        $subject->code=$request->code;
        $subject->group=$request->group;
        $subject->multiple=$request->multiple;
        $subject->save();

        foreach($request->options as $key => $opt){
            $option=new Option;
            $option->option=$opt;
            $option->subject_id=$subject->id;
            $option->ans=$request->ans[$key];
            $option->save();
        }
        return redirect()->route('backstage.bank');
    }
}

測試一下:

subjects有寫入題目

options有寫入選項,同時題目的id及答案的標示也都有

題庫列表(Read)

新增成功後,畫面會redirect回題庫列表的頁面,這時我們可以讓所有的題目列出來,但考量到未來題目可能很多,所以我們應該是讓每個題庫編號有一個對應的中文名字,一但新增完回到列表時,應該是顯示題庫編號的列表,及每個題庫中目前共有多少題目的計數。

但是目前先簡單做,就先全部題目列出來就好,並顯示目前的題目總數。

app\Http\Controllers\BackstageController.php

function bankList()
{
    $subjects=Subject::all();
    $count=Subject::count();
    return Inertia::render('Backstage/Banks',
                    [
                     'subjects'=>$subjects,
                     'count'=>Subject::count()
                    ]);
}

resources\js\Pages\Backstage\Banks.vue

<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link } from "@inertiajs/inertia-vue3";

const props = defineProps({ subjects: Array, count: Number });
</script>

<template>
  <Head title="管理中心" />

  <AuthenticatedLayout>
    <template #header>
      <h2 class="font-semibold text-xl text-gray-800 leading-tight">管理中心</h2>
    </template>
         //....略過左側選單
     <div class="w-5/6 border rounded-xl p-4">
       <Link :href="route('bank.create')"
             class="inline-block py-2 px-3 border rounded-xl bg-blue-700 text-blue-100 my-4">
         新增題庫
       </Link>
       <div class="w-full px-4 py-2">題目總數:{{ count }}</div>
       <div v-for="subject in subjects"
            :key="subject.id"
            class="w-full border rounded-xl flex p-4 bg-green-400 justify-between">
         <div>
           {{ subject.seq }}.
           {{ subject.subject }}
         </div>
         <div>編輯 / 刪除</div>
       </div>
     </div>
  </AuthenticatedLayout>
</template>

編輯題目(Update)

編輯題目使用的表單和新增題目的表單是一樣的,只是差在有沒有資料而己,現在只是先驗證一下CRUD沒問題,所以我們先直接複製CreateBank.vue來使用,之後會再提到前端的頁面如何做組件的拆分:

先新增兩個路由,一個是用來顯示編輯表單的路由,一個是要更新資料內容的路由。
routes\web.php

Route::get('/backstage/bank/edit/{id}',[BackstageController::class,'edit'])->name('bank.edit');
Route::put('/backstage/bank/{id}',[BackstageController::class,'update'])->name('bank.update');

接著撰寫Controller中對應的動作:
app\Http\Controllers\BackstageController.php

//取出資料後,回傳給前端組件使用
function edit($id)
{
    $subject=Subject::find($id);
    $options=$subject->options;
    return Inertia::render('Backstage/EditBank',
                    [
                        'subject'=>$subject,
                        'options'=>$options
                    ]);
}

//接收表單傳來的資料,並進行資料的更新
function update(Request $request,$id)
{
    $subject=Subject::find($id);
    $subject->subject=$request->subject;
    $subject->seq=$request->seq;
    $subject->code=$request->code;
    $subject->level=$request->level;
    $subject->group=$request->group;
    $subject->multiple=$request->multiple;
    $subject->save();

    //選項採逐筆更新的方式
    foreach($request->options as $key => $opt){
        $option=Option::find($opt['id']);
        $option->option=$opt['option'];
        $option->ans=$opt['ans'];
        $option->save();
    }
    return redirect()->route('backstage.bank');
}

在題庫列表中加上編輯的連結行為

<Link :href="route('bank.edit', subject.id)">編輯</Link>

修改編輯表單

resources\js\Pages\Backstage\EditBank.vue

<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link, useForm } from "@inertiajs/inertia-vue3";

const props = defineProps({
  subject: Object,
  options: Array,
});

//Inertia的表單物件
const form = useForm({
  code: props.subject.code,
  level: props.subject.level,
  group: props.subject.group,
  multiple: props.subject.multiple,
  seq: props.subject.seq,
  subject: props.subject.subject,
  options: props.options,
});

//表單傳送時改用Inertia的Form組件來傳送,這是一個ajax的傳送
const submit = () => {
  form.put(route("bank.update", props.subject.id));
};
</script>

<template>
  <Head title="管理中心" />

  <AuthenticatedLayout>
    <template #header>
      <h2 class="font-semibold text-xl text-gray-800 leading-tight">管理中心</h2>
    </template>
    //.....略過左側選單
	<div class="w-5/6 border rounded-xl p-4">
	  <!--利用Vue的動作修飾詞來中斷表單submit的動作,並觸發自訂的submit函式-->
	  <form @submit.prevent="submit">
	    <div>
	      <label>題庫編號:</label>
	      <input type="number" name="code" v-model="form.code" class="w-24" />
	      <label>級別:</label>
	      <input type="number" name="level" v-model="form.level" class="w-12" />
	      <label>題組:</label>
	      <input type="number" name="group" v-model="form.group" class="w-12" />
	      <input type="radio" name="multiple" v-model="form.multiple" value="0" /> 單選
	      <input type="radio" name="multiple" v-model="form.multiple" value="1" /> 複選
	    </div>
	    <div class="my-1">
	      <label>題號:</label>
	      <input type="number" name="seq" min="1" v-model="form.seq" />
	    </div>
	    <div class="my-1">
	      <label>題目:</label>
	      <input type="text" name="subject" v-model="form.subject" class="w-[90%]" />
	    </div>
	    <div class="my-1">
	      <label for="">選項1:</label>
	      <input type="text" class="w-[90%]" v-model="form.options[0].option" />
	      <input type="checkbox" v-model="form.options[0].ans" />
	    </div>
	    <div class="my-1">
	      <label for="">選項2:</label>
	      <input type="text" class="w-[90%]" v-model="form.options[1].option" />
	      <input type="checkbox" v-model="form.options[1].ans" />
	    </div>
	    <div class="my-1">
	      <label for="">選項3:</label>
	      <input type="text" class="w-[90%]" v-model="form.options[2].option" />
	      <input type="checkbox" v-model="form.options[2].ans" />
	    </div>

	    <div class="my-1">
	      <label for="">選項4:</label>
	      <input type="text" class="w-[90%]" v-model="form.options[3].option" />
	      <input type="checkbox" v-model="form.options[3].ans" />
	    </div>
	    <button type="submit" 
                class="border py-2 px-4 bg-blue-700 text-blue-100 rounded-xl my-2">
	          修改
	    </button>
	  </form>
	</div>
  </AuthenticatedLayout>
</template>

刪除題庫(Delete)

刪除是個好議題,一般有分為軟刪除和硬刪除,電商或金流相關的應用會優先選擇軟刪除,儘可能保留所有的資料,但我這個應用沒那麼高大上,所以我們選擇硬刪除就可以了。

先建立路由
routes\web.php

Route::delete('/backstage/bank/{id}',[BackstageController::class,'destroy'])->name('bank.destroy');

接著撰寫Controller中對應的動作:
app\Http\Controllers\BackstageController.php

function destroy($id)
{
    $subject=Subject::find($id);
    $options=$subject->options;
    $subject->delete();
    $options->map(function($opt){
        $opt->delete();
    });
}

在題庫列表中加上刪除的連結行為,method要和路由設定的delete一致
resources\js\Pages\Backstage\Banks.vue

<Link :href="route('bank.destroy', subject.id)" method="delete" as="button">
    刪除
</Link>

這樣我們就完成了一套CRUD的簡單測試了,對Laravel和Vue的使用也更熟悉了,距離成為一個網頁全端工程師也不遠了...

不,還很遠。。。

我目前為止都是很草莽,很粗魯的先儘快讓畫面和資料能動起來,model的關聯也只是小測一下,這一切都還只是正式開工的前置作業而已,我單純想先驗證一些開發的套路是順暢的,會不會有什麼還沒想到的。

所以很明顯的在這過程中我們留下了不少技術債和缺失,如果不趕快撥亂反正,雖然依舊可以完成專案,但會有一堆重覆的程式碼和日後難以改動的相互依賴關係存在,造成牽一髮動全身的狀況,簡單陳列幾個項目:

  1. BackstageController 應該只負責後台首頁有那些功能要出現就好,它不應該去操作每一項功能中的資料,比如我們現在在其中放了banks、quizzes、tests、groups的四個list函式,而這四個功能都還有CRUD相關的至少五個函式要管理,全部寫在一個class的話,在命名和維護上都會是一場災難,同時將來要在後台新增功能的話,那又要增加一整組至少七個函式,這個 Cotroller 會變得很巨大。
  2. Controller 中直接引入 Model 來操作雖然很直覺,但是一但資料的邏輯變複雜時,Controller 也會變得肥大,比如我們在把題目的選項傳給前端 Vue 時,必須先把在資料表中以字串型式儲存的布林值 1 或 0 轉成前端可以使用的布林值 true 或 false,當這樣的動作變多時,一個Controller中的函式除了要驗證參數,控制資料進出之外,還要負責資料的商業邏輯,一個函式可能都要上百行的程式碼,閱讀和維護或擴充都變得不容易。
  3. 後台的頁面檔案都一直跟著選單在複製,如果將來要多一個選單的話,那全部的頁面都要做修改,這很要命,所以我們要善用前端框架的特性,把這些高度重覆的部份組件化,讓頁面的結構可以更單純一些。
  4. 前端表單的行為應該一致性更高一些,比如新增和編輯的表單行為只差在資料的有無,但我們在撰寫時,新增的資料物件中,options和ans是分開兩個字串陣列,而在編輯時,options是一個物件陣列,陣列中的每個物件都包含了選項文字和是否為答案的項目;我們如果可以讓這兩個表單的資料型式和行為更一致的話,那事實上我們只需要一個表單組件就可以同時處理新增和編輯了。

我們到目前為此已經驗證了從 頁面 -> 路由 -> 控制器 -> 資料(model),一整套基礎的應用是沒問題的,也抓到一些固定的套路來開發之後的應用;所以我們不用擔心功能能不能完成的問題了,那麼下一步,我們先來處理一下以上的技術債問題,讓之後的開發可以更有規範一些。


上一篇
Day07 建立功能連結及頁面
下一篇
Day09 拆分後端的邏輯-Service And Repository
系列文
LV的全端開發體驗30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
Pallas
iT邦新手 3 級 ‧ 2023-06-28 11:36:26

感謝您寫的文章,我新手自學 laravel 對我有相當大的幫助

因為可能時空背景不一樣,所以有些東西可以作個修正
例如:Subject Models

class Subject extends Model
{
use HasFactory;
protected $guarded=[];
protected $hidden=['created_at','updated_at'];

function options()
{
    return $this->hasMany(Option::class);
}

}

mackliu iT邦新手 4 級 ‧ 2023-06-30 20:25:08 檢舉

感謝指正..
應該是漏打了~~~

我要留言

立即登入留言